使用 Electron 内置 Node & 动态加载模块
概述
Electron 的渲染进程是 Node.js 隔离环境,出于安全考虑不直接暴露 Node API。本节介绍如何通过 IPC 通信机制,让渲染进程安全地调用主进程中的 Node.js 能力,并实现插件的动态加载机制。
进程架构与通信模型
渲染进程与主进程的职责划分
渲染进程(浏览器环境) 主进程(Node.js 环境)
┌─────────────────────┐ ┌─────────────────────┐
│ 输入框 UI │ │ Node.js API │
│ 搜索补全列表 │─IPC──→│ 执行 CLI 命令 │
│ 键盘导航 │ │ 动态加载插件模块 │
│ 结果展示 │←─IPC──│ 返回执行结果 │
└─────────────────────┘ └─────────────────────┘
text
IPC 通信方式对比
| 方式 | API | 特点 | 适用场景 |
|---|---|---|---|
| 单向同步 | ipcRenderer.send + ipcMain.on | 阻塞渲染进程 | 不推荐 |
| 单向异步 | ipcRenderer.send + ipcMain.on | 非阻塞,无返回值 | 通知类事件 |
| 双向异步 | ipcRenderer.invoke + ipcMain.handle | 非阻塞,支持返回值 | 推荐:命令执行 |
| 渲染进程间 | MessagePort | 进程间直接通信 | 多窗口通信 |
推荐使用 invoke/handle 模式,因为其支持 Promise 返回值且不阻塞渲染进程。
实现代码
Preload 桥接层(contextBridge)
// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
/**
* 执行插件命令
* @param command 插件命令名
* @param args 命令参数
*/
executeCommand: (command: string, args?: string[]) =>
ipcRenderer.invoke('plugin:execute', command, args),
/** 获取可用插件列表 */
getPlugins: () =>
ipcRenderer.invoke('plugin:list'),
/** 加载指定插件 */
loadPlugin: (pluginId: string) =>
ipcRenderer.invoke('plugin:load', pluginId),
/** 监听命令执行结果 */
onCommandResult: (callback: (result: unknown) => void) =>
ipcRenderer.on('plugin:result', (_event, result) => callback(result))
})
typescript
主进程:Node.js 命令执行与动态模块加载
// main/pluginManager.ts
import { ipcMain, BrowserWindow } from 'electron'
import { exec } from 'node:child_process'
import { pathToFileURL } from 'node:url'
import path from 'node:path'
interface Plugin {
id: string
name: string
entry: string // 插件入口文件路径
commands: string[] // 插件注册的命令列表
}
const loadedPlugins = new Map<string, Plugin>()
/**
* 注册 IPC handlers
*/
export function registerPluginHandlers(): void {
// 执行插件命令
ipcMain.handle('plugin:execute', async (_event, command: string, args: string[] = []) => {
return executePluginCommand(command, args)
})
// 获取插件列表
ipcMain.handle('plugin:list', async () => {
return getAvailablePlugins()
})
// 动态加载插件
ipcMain.handle('plugin:load', async (_event, pluginId: string) => {
return loadPlugin(pluginId)
})
}
/**
* 执行插件命令
*/
function executePluginCommand(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
exec(`${command} ${args.join(' ')}`, { timeout: 30000 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message))
} else {
resolve(stdout)
}
})
})
}
/**
* 动态加载插件模块
* 支持两种方式:
* 1. 直接执行 JS 文件
* 2. 作为 ES Module 动态导入
*/
async function loadPlugin(pluginId: string): Promise<Plugin> {
const pluginDir = path.join(process.cwd(), 'plugins', pluginId)
const entryPath = path.join(pluginDir, 'index.js')
try {
// 方式一:作为 ES Module 动态导入
const pluginUrl = pathToFileURL(entryPath).href
const module = await import(pluginUrl)
const plugin: Plugin = {
id: pluginId,
name: module.name || pluginId,
entry: entryPath,
commands: module.commands || []
}
loadedPlugins.set(pluginId, plugin)
return plugin
} catch {
// 方式二:作为 CommonJS 加载
try {
const module = require(entryPath)
const plugin: Plugin = {
id: pluginId,
name: module.name || pluginId,
entry: entryPath,
commands: module.commands || []
}
loadedPlugins.set(pluginId, plugin)
return plugin
} catch (err) {
throw new Error(`插件 ${pluginId} 加载失败: ${(err as Error).message}`)
}
}
}
/**
* 获取可用插件列表
*/
function getAvailablePlugins(): Plugin[] {
return Array.from(loadedPlugins.values())
}
typescript
渲染进程:调用主进程执行命令
<!-- renderer/components/CommandInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const inputText = ref('')
const result = ref('')
const loading = ref(false)
async function executeCommand(): Promise<void> {
if (!inputText.value.trim()) return
loading.value = true
try {
const output = await window.electronAPI.executeCommand(inputText.value)
result.value = output
} catch (err) {
result.value = `错误: ${(err as Error).message}`
} finally {
loading.value = false
}
}
async function loadPlugin(pluginId: string): Promise<void> {
const plugin = await window.electronAPI.loadPlugin(pluginId)
console.log('插件加载成功:', plugin.name)
}
</script>
<template>
<div class="command-input">
<el-input
v-model="inputText"
placeholder="输入命令..."
@keyup.enter="executeCommand"
>
<template #append>
<el-button :loading="loading" @click="executeCommand">执行</el-button>
</template>
</el-input>
<pre v-if="result" class="result">{{ result }}</pre>
</div>
</template>
vue
动态加载模块的两种方式
| 方式 | API | 模块格式 | 适用场景 |
|---|---|---|---|
| ESM 动态导入 | import(url) | ES Module | 推荐方式,支持异步 |
| CJS 动态加载 | require(path) | CommonJS | 兼容旧模块 |
| child_process | exec(command) | 任意可执行文件 | 独立 CLI 工具 |
关键安全考虑
// ❌ 危险:在渲染进程中直接使用 Node API
// contextIsolation: true 时不可用
// ✅ 安全:通过 contextBridge 暴露有限 API
contextBridge.exposeInMainWorld('electronAPI', {
executeCommand: (cmd: string) => ipcRenderer.invoke('plugin:execute', cmd)
})
// ✅ 主进程中对执行的命令做白名单校验
const ALLOWED_COMMANDS = ['git', 'node', 'npm', 'pnpm']
function validateCommand(command: string): boolean {
const base = command.split(' ')[0]
return ALLOWED_COMMANDS.includes(base)
}
typescript
实践要点
- 渲染进程是 Node.js 隔离环境,所有 Node API 调用必须通过 IPC 交由主进程执行
- 推荐使用
ipcRenderer.invoke+ipcMain.handle双向异步通信模式 contextBridge.exposeInMainWorld是安全的桥接方式,仅暴露预定义的 API 方法- 动态加载插件支持 ESM(
import())和 CJS(require())两种方式,优先尝试 ESM - 主进程执行外部命令时应做白名单校验,防止命令注入风险
- 插件加载失败时应提供明确的错误信息,方便调试
↑